Skip to content

Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins - #5956

Open
glageju wants to merge 25 commits into
mainfrom
gautam/metrics-std-toolhive
Open

Standardize proxy + vMCP metrics to stacklok.*, delete legacy twins#5956
glageju wants to merge 25 commits into
mainfrom
gautam/metrics-std-toolhive

Conversation

@glageju

@glageju glageju commented Jul 23, 2026

Copy link
Copy Markdown

Summary

The proxy and vMCP used toolhive_mcp_* / toolhive_vmcp_* metric names, emitted six legacy metrics alongside their emerging-semconv replacements, and diverged from the platform label vocabulary. This finishes the semconv migration and adopts the shared toolhive-core vocabulary, per the Metrics Standardization RFC.

  • Proxy (pkg/telemetry/): toolhive_mcp_active_connectionsstacklok.toolhive.proxy.active_connections; build_info via coremetrics.RegisterBuildInfo; added http.server.request.duration (OTel HTTP semconv) so deleting the legacy MCP twins doesn't drop transport-level coverage; D8 ownership labels (stacklok_component=toolhive, stacklok_product=stacklok-platform) promoted to every series via WithResourceAsConstantLabels and hardened as the final OTEL resource detector so a CLI custom attribute or OTEL_RESOURCE_ATTRIBUTES entry for those keys cannot override the frozen values.
  • vMCP (pkg/vmcp/): toolhive_vmcp_workflow_*stacklok.vmcp.composite_tool.*; backend mcp_server.health gauge rename to a live per-(mcp_server, state) gauge, keyed by workload ID for consistency with the backend registry and health-status provider; optimizer renames + token_savings.
  • ⚠️ Breaking: six legacy semconv twins deleted (toolhive_mcp_requests, toolhive_mcp_request_duration, toolhive_mcp_tool_calls, toolhive_vmcp_backend_requests, toolhive_vmcp_backend_errors, toolhive_vmcp_backend_requests_duration). Their semconv replacements (mcp.server.operation.duration, mcp.client.operation.duration, http.server.request.duration) are kept.
  • Bumps the toolhive-core dependency to v0.0.34, the release carrying telemetry/metrics (adopts core label keys, outcome values, and bucket presets). The transitive AWS-SDK and grpc bumps in go.mod come from toolhive-core's own requirements.
  • Updates integration and e2e tests (test/integration/vmcp/, test/e2e/) that asserted the deleted/renamed metric names and old label scheme (mcp_methodmcp_method_name, servermcp_server, status→HTTP status code) to assert the semconv replacements.
  • Updates the Grafana example dashboards and docs/telemetry-migration-guide.md for the renamed/deleted metrics, including a complete old→new metric and label mapping and a corrected MCP Request Rate vs. total HTTP request-rate query split.
  • Enables the Grafana dashboard sidecar in the example otel dev stack (examples/otel/prometheus-stack-values.yaml) so the updated example dashboards can be auto-provisioned into a local Kind cluster via a labeled ConfigMap, for validating the renamed metrics land correctly end-to-end.
  • Extends the rename to pkg/ratelimit/observability.go, missed by the original pass: toolhive_rate_limit_decisions/_redis_errors/_check_latencystacklok.toolhive.ratelimit.{decisions,redis_errors,check_latency}, with the migration guide and docs/observability.md updated to match.
  • Renames toolhive_vmcp_backend_revision_reclassifications (added independently on main after this branch forked) to stacklok.vmcp.backend.revision_reclassifications so it doesn't reintroduce the legacy naming scheme this PR retires.
  • Clarifies docs/telemetry-migration-guide.md's Backward Compatibility section: span attributes and metrics follow different policies (dual-emission vs. hard cutover) for different reasons — the guide previously implied one uniform strategy.

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Verified the shipped standalone path (GOWORK=off, published toolhive-core v0.0.34, no workspace):

  • GOWORK=off go build ./... — clean.
  • GOWORK=off go test ./pkg/telemetry/... ./pkg/vmcp/... — all packages pass, including the D8 label-promotion assertion in pkg/telemetry/buildinfo_test.go (stacklok_component="toolhive" / stacklok_product="stacklok-platform" promoted to per-series labels) and the updated TestVMCPServer_Telemetry_CompositeToolMetrics integration test.
  • task test (full suite) is green.
  • The e2e telemetry tests (test/e2e/) were updated for the new metric/label vocabulary and compile clean (go vet); they require a full task test-e2e run (containers) to exercise end-to-end.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Does this introduce a user-facing change?

Yes. Prometheus metric names emitted by the proxy, vMCP, and rate limiting change to the stacklok.* namespace, and six legacy toolhive_* twins are removed. Their OTel-semconv replacements cover the same signals; docs/telemetry-migration-guide.md maps old → new. A customer scraping the old names loses those series (RFC D13 accepts this: the deleted twins are not scraped in a default shipped deployment).

Special notes for reviewers

  • Depends on toolhive-core v0.0.34 (the shared-vocabulary release); this PR's go.mod pins it.
  • toolhive-core v0.0.34 trims the Component* value roster a pre-release build referenced; toolhive defines its own stacklok.component = "toolhive" constant (pkg/telemetry/middleware.go, pkg/telemetry/providers/providers.go), asserted by pkg/telemetry/buildinfo_test.go.
  • pkg/vmcp/internal/backendtelemetry/backendtelemetry.go's health gauge resolves each backend's status with a three-tier precedence — a live health.StatusProvider first, then request-outcome tracking, then the registry's discovery-time snapshot — matching the same precedence filterHealthyBackends uses for capability aggregation. core.New unregisters the gauge's callback on every construction-failure path after registration succeeds, and on Close(), so a partially-constructed coreVMCP never leaks a callback against the shared meter provider.

Links

Ref: https://github.com/stacklok/stacklok-enterprise-platform/issues/1380
Generated with Claude Code

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 23, 2026
The D8 ownership labels stacklok.component and stacklok.product are meant
to be frozen per-series identity, but they were applied to the OTEL resource
before CustomAttributes and WithFromEnv. Because resource detectors merge
last-wins, a CLI custom attribute or OTEL_RESOURCE_ATTRIBUTES entry for
either key silently overrode the frozen values, letting stacklok_component
and stacklok_product drift in the Prometheus output.

Apply the two reserved attributes as the final resource detector so they
always win over user- and env-supplied attributes.
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.67241% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.60%. Comparing base (32f75d9) to head (a8c3103).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pkg/telemetry/middleware.go 85.00% 7 Missing and 2 partials ⚠️
pkg/vmcp/core/core_vmcp.go 88.88% 2 Missing and 1 partial ⚠️
...vmcp/internal/backendtelemetry/backendtelemetry.go 95.38% 3 Missing ⚠️
pkg/telemetry/providers/providers.go 90.90% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5956      +/-   ##
==========================================
- Coverage   72.61%   72.60%   -0.01%     
==========================================
  Files         736      736              
  Lines       76340    76372      +32     
==========================================
+ Hits        55431    55452      +21     
- Misses      16973    17008      +35     
+ Partials     3936     3912      -24     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
glageju added 2 commits July 24, 2026 11:05
Multiple review passes on the metrics-standardization migration (proxy +
vMCP -> stacklok.* semconv vocabulary) surfaced gaps left by the cutover:

- Backend health gauge leaked series for backends removed from the
  registry (e.g. via list_changed) since it tracked membership in an
  internal map that only grew. MonitorBackends now takes the
  BackendRegistry directly and re-derives live backends from
  registry.List on every collection.
- The health gauge collapsed BackendHealthStatus's five states into a
  boolean, losing degraded/unknown/unauthenticated information the
  registry already carries before any request completes. The gauge now
  emits one point per real status value.
- A construction failure in core.New occurring after MonitorBackends
  registered its gauge callback (audit config, workflow validation,
  health monitor setup) never unregistered it, leaking the callback
  against the shared meter provider for every failed New attempt.
- Docs (observability.md, virtualmcpserver-observability.md,
  telemetry-migration-guide.md) and four Grafana example dashboards
  still referenced deleted/renamed legacy metric and label names.
- Minor cleanup: an unnamed "not_found" outcome string, inconsistent
  receiver types on telemetryBackendClient, and a discarded metric
  registration handle.

Adds unit test coverage for the health gauge's registry-driven
membership, its multi-state reporting, and a regression test proving
core.New unregisters the callback on every post-registration failure
path (verified by temporarily reverting the fix and confirming the new
test fails).

Generated with [Claude Code](https://claude.com/claude-code)
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
The metrics-standardization migration lost information the deleted
legacy metrics carried and left downstream artifacts partially
migrated: mcp.client.operation.duration had no backend identity label,
three Grafana dashboards still queried deleted metric names, and the
new stacklok.vmcp.mcp_server.health gauge never consulted the live
health monitor, so it could disagree with capability filtering.

- Add mcp_server to mcp.client.operation.duration's attributes
- Migrate remaining stale panels in 3 Grafana dashboards to the
  semconv metric/label vocabulary
- Thread a live health.StatusProvider into the backend-health gauge
  via a new HealthProviderSetter, matching filterHealthyBackends'
  precedence (live provider > recorded outcome > registry snapshot),
  and normalize an empty HealthStatus to healthy instead of reporting
  every state as 0
- Cover all 5 of New()'s post-registration error paths for backend
  health callback unregistration, not just one
- Hoist the duplicated componentName constant into providers.ComponentName
- Fix Close()'s stale doc comment and rename unregisterHealthOnError to
  unregisterHealthLogged (it also runs on the normal shutdown path)
- Document that NewMeteredTokenCache has no production caller yet
- Add the vMCP optimizer and mcp_server.health rows to the migration
  guide's metric mapping table

Generated with Claude Code

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
error_type on the semconv metrics is only set for HTTP >=500, so the
dashboard error-rate panels' error_type!="" filter silently excluded
4xx failures (authz denials, classification rejects) the old
status!="success" caught. Switch those panels to the HTTP
duration metric's status-code label, which sees every request.

The migration guide's request-volume guidance summed
mcp_server_operation_duration_seconds_count and
http_server_request_duration_seconds_count, but every MCP-method-bearing
request increments both, double-counting them. Use the HTTP duration
metric alone for total volume.

Generated with Claude Code

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 24, 2026
Grafana total-RPS panels queried the MCP-only histogram, undercounting
transport-level traffic; the migration guide pointed at a label that
doesn't exist on its replacement metric; several D8/build_info/health-gauge
doc sections contradicted the actual implementation; and the proxy
middleware could panic on a rare instrument-registration failure. Also
drops the token-cache metrics decorator shipped with no production caller.
Closes documentation and test-coverage gaps surfaced during review of
the stacklok.* metrics migration: the new mcp_server label on
mcp.client.operation.duration was undocumented in two places, the
revision-reclassification rename was missing from the migration
guide's mapping table, the health-gauge's doc comment described the
wrong divergence condition from filterHealthyBackends, the Grafana
sidecar's cluster-wide searchNamespace had no RBAC note or setup
steps, and the D8 ownership-label wiring in providers_strategy.go had
no test through its actual construction path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 28, 2026

@glageju glageju left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Reviewed the metrics-vocabulary migration end to end: the proxy/vMCP rename to stacklok.*, the six-metric deletion, D8 ownership-label hardening, and the vMCP backend health-gauge rework.

Findings addressed in 045ccad

  • mcp_server label missing from mcp.client.operation.duration's documentation in two places (docs/operator/virtualmcpserver-observability.md, docs/telemetry-migration-guide.md)
  • currentHealthStatus's doc comment (pkg/vmcp/internal/backendtelemetry/backendtelemetry.go) described the wrong divergence condition from filterHealthyBackends — corrected to state it occurs when provider == nil and record() has an entry, not the "provider set but not yet tracking" case it originally claimed
  • Grafana sidecar searchNamespace: ALL (examples/otel/prometheus-stack-values.yaml) had no RBAC note or ConfigMap setup steps — scoped to the monitoring namespace with a comment on the tradeoff, and added the kubectl create configmap/label steps to examples/otel/README.md
  • D8 ownership-label wiring in providers_strategy.go had no test through its actual UnifiedMeterStrategy.CreateMeterProvider construction path — added TestUnifiedMeterStrategy_PrometheusOwnershipLabels
  • toolhive_vmcp_backend_revision_reclassifications's rename was missing from the migration guide's mapping table

Verified, not bugs

  • Using OTel dotted attribute-key constants (stacklok.component) as Prometheus label-map keys in providers_strategy.go does not panic MustRegister as initially suspected — prometheus/common's UTF8Validation default (active in this repo's pinned v0.67.5) accepts dots and escapes them to underscore form at scrape time. Confirmed by reproducing the exact registration + scrape against this repo's pinned dependencies.
  • The toolhive-core v0.0.34 pin in go.mod is not visible as a diff hunk because it already landed via an earlier merge from main — the branch builds and compiles cleanly.

Overall

Callback lifecycle (register → unregister-on-every-construction-failure-path → idempotent Close()), the D8 label-override hardening, and the six metric deletions/renames are all correctly implemented and backed by tests that exercise the actual failure paths, not just the happy path — including a regression test that scrapes a live Prometheus handler to prove no gauge series leak after a construction failure.


Generated with Claude Code

@glageju
glageju marked this pull request as ready for review July 28, 2026 04:30
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 28, 2026

@aponcedeleonch aponcedeleonch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes.

The RFC alignment here is solid, and the migration guide is more thorough than most renames get. The health-gauge test coverage stands out: the precedence tests and the registry-removal case cover things that would normally slip through.

My concern is the cutover strategy rather than the vocabulary itself.

As it stands, no ToolHive metric keeps its name. After this PR the string toolhive_ appears in zero metric names across the non-test Go source. That's about 24 metrics: 6 deleted outright and 18 renamed. A rename breaks a dashboard the same way a deletion does, so from a user's point of view every panel and alert they have goes empty on upgrade, all at once.

The PR cites RFC D13 to accept that cost, but D13's argument is that the deleted twins aren't scraped in a default shipped deployment. That covers the 6 deletions. It doesn't cover the 18 renames, which are the larger share and have no stated justification for breaking.

We already have the machinery for this. Span attributes get dual emission behind --otel-use-legacy-attributes, with #6072 tracking its retirement. Metrics went with a hard cutover instead, and I don't think the PR explains why they should be treated differently.

What I'd like instead:

  • Dual-emit the 18 renamed metrics under both names, behind a flag defaulting to on.
  • Keep the 6 deleted twins one more release. They already coexist with their semconv replacements today, so keeping them costs nothing new. Removing them in the same release as the mass rename is what leaves users with no working query for anything.
  • Emit a startup WARN naming the deprecated metrics and the release they'll be removed in.
  • Call this out prominently in the release notes as a breaking change.
  • Remove both in a later minor.

Dual emission roughly doubles series for the overlap window, and histograms are the expensive case. If that's the blocker, I'd rather talk about a shorter window than skip it, because right now the only thing absorbing the breakage is our users' dashboards. Also worth flagging that green CI isn't much signal here: we ship no PrometheusRule or alert artifacts, so there's nothing in-repo for CI to catch.

One thing to watch on the rebase. The branch is 44 commits behind main and conflicts on pkg/telemetry/middleware.go. The version bump sorts itself out, but the conflict still needs a call on which histogram preset mcp.server.operation.duration ends up on: #6144 on main put it on BucketsMCPSemconv(), this branch uses BucketsMCPProxy(). Since the metric carries a semconv name I'd keep the semconv buckets. pkg/ratelimit/observability.go:80 and pkg/vmcp/core/core_telemetry.go:57 make the same substitution without conflicting textually, so they want a look too.

func (m *HTTPMiddleware) recordHTTPServerDuration(
ctx context.Context, method string, statusCode int, duration time.Duration,
) {
specAttrs := []attribute.KeyValue{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This attribute set has no mcp_server, and neither does the one in recordOperationDuration just below. The three deleted proxy metrics (toolhive_mcp_requests, _request_duration, _tool_calls) all carried server, so per-server breakdown on request metrics goes away entirely. Only stacklok.toolhive.proxy.active_connections still has it. This needs sorting before merge.

The migration guide says otherwise at docs/telemetry-migration-guide.md:281, which maps server (proxy + rate limit metrics) to mcp_server with no caveat. That's accurate for the rate-limit metrics and wrong for these two.

Two ways out: add coremetrics.LabelMCPServer here and in recordOperationDuration, or fix that guide row and say per-server breakdown now relies on scrape-target labels or a target_info join. I'd lean toward the label, since one Prometheus scraping several proxies otherwise can't split them without a join.

Worth noting the e2e assertion for mcp_server="<name>" in test/e2e/osv_authz_test.go still passes, because active_connections supplies it. So the tests don't catch this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 46864d8 — added coremetrics.LabelMCPServer to both recordHTTPServerDuration and recordOperationDuration, and fixed the migration guide row that claimed servermcp_server applied unconditionally. Also strengthened the assertions in TestHTTPMiddleware_HTTPServerRequestDuration to check for the label going forward.

// connection. Record the HTTP semconv duration on close so the
// long-lived SSE-open GET (which never reaches recordMetrics) still
// contributes a transport-level observation.
sseStart := time.Now()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This records the whole SSE connection lifetime into http.server.request.duration, which is on BucketsFastHTTP() and tops out at 10s. SSE connections live for minutes or hours, so every one lands in +Inf and any quantile over the unfiltered metric stops meaning much.

The example dashboards only use _count, so they're fine today. But docs/observability.md:234 documents the SSE behavior without mentioning the effect, so someone will build a p95 panel on this and get a flat line pinned to the top bucket.

Either record the SSE observation into a long-running bucket set, or note in the docs that latency queries need to filter http_request_method="POST".

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 46864d8 — split SSE connection duration into a dedicated stacklok.toolhive.proxy.sse_connection.duration histogram on BucketsMCPProxy() (tops out at 300s), rather than sharing http.server.request.duration's 10s-capped buckets. Updated docs/observability.md with the new metric's section and a note on why they're kept separate. Rewrote TestHTTPMiddleware_HTTPServerRequestDuration_SSEBranch (now TestHTTPMiddleware_SSEConnectionDuration_SSEBranch) to assert http.server.request.duration is no longer emitted on the SSE branch and the new histogram is.

promConfig := prometheus.Config{
EnableMetricsPath: true,
IncludeRuntimeMetrics: true,
OwnershipLabels: map[string]string{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are OTel attribute keys (stacklok.component, with a dot), but OwnershipLabels is documented as Prometheus constant labels and goes straight into promclient.WrapRegistererWith at prometheus.go:48.

It works today, though only because model.NameEscapingScheme defaults to UnderscoreEscaping and rewrites the dot at exposition time. A scraper negotiating escaping=allow-utf-8, which Prometheus 3.x can do, gets stacklok.component verbatim on the go_* and process_* series while OTel-native series keep stacklok_component. A dashboard filtering on stacklok_component then silently drops all runtime metrics.

TestUnifiedMeterStrategy_PrometheusOwnershipLabels says it guards against "a wrong-form key (e.g. an OTel dotted attribute key instead of a Prometheus label name)" and passes anyway, for the same escaping reason. Passing stacklok_component and stacklok_product directly here makes it robust, and makes that test mean what it says.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 46864d8. You're right that this was only accidentally safe under the default negotiation. I reproduced the split with escaping=allow-utf-8: runtime/process series exposed "stacklok.component" verbatim while OTel-native series stayed stacklok_component, since the OTel exporter's own attribute-to-label conversion is unconditionally underscore regardless of scrape negotiation. Switched OwnershipLabels to hardcoded Prometheus-form keys directly, with a comment explaining why the OTel dotted constants don't belong here. Also strengthened TestUnifiedMeterStrategy_PrometheusOwnershipLabels to negotiate escaping=allow-utf-8 in a second subtest and assert the dotted form never appears. Confirmed it fails against the pre-fix code and passes now.

Comment thread docs/telemetry-migration-guide.md Outdated
dashboard or alert querying an old metric or label name must be updated
regardless of that flag's setting.

| Legacy Metric/Label | New Metric/Label | Notes |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The left column holds real Prometheus names and the right column holds OTel dotted names, so anyone pasting a value from the right column into PromQL gets zero results. This needs fixing whichever cutover path we settle on, otherwise the guide can't actually be followed.

Rows needing _total: 286, 287, 289, 290, 294, 298, 300. Rows needing _seconds plus _bucket/_sum/_count: 288, 291, 292, 295, 299, 315, 316. And stacklok.vmcp.optimizer.token_savings carries unit %, so it exposes as stacklok_vmcp_optimizer_token_savings_percent, which happens to match the old Prometheus name exactly and is worth saying out loud.

Lines 324 to 330 do the translation correctly for the two HTTP/MCP metrics, which makes the omission in the tables read as deliberate. A Prometheus-name column would fix it, or one sentence above the tables covering dots to underscores, _total, and unit suffixes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 46864d8. Added a Prometheus Name column to the mapping table with the correctly suffixed underscore form for every row (_total for counters, _seconds_bucket/_sum/_count for histograms, and the declared-unit suffix like _percent where applicable). Verified each suffix empirically by constructing the actual instrument kind and scraping it, rather than assuming the general rule holds for every case, since the unit suffixes (_percent, {tools} producing no unit infix) needed confirming.

Comment thread docs/telemetry-migration-guide.md Outdated
| `toolhive_vmcp_workflow_executions` | `stacklok.vmcp.composite_tool.executions` | Now split by `outcome` label instead of a separate errors counter |
| `toolhive_vmcp_workflow_errors` | `stacklok.vmcp.composite_tool.executions` (filtered to `outcome="error"`) | Merged into the executions counter above, not a standalone metric |
| `toolhive_vmcp_workflow_duration` | `stacklok.vmcp.composite_tool.duration` | |
| `toolhive_vmcp_backend_requests_duration` | `mcp.client.operation.duration` | OTEL MCP semconv histogram |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing: toolhive_vmcp_backend_requests_duration appears twice, here as a rename and at line 319 in the deleted table, whose preamble says there's no renamed successor. Its two siblings (toolhive_vmcp_backend_requests and _backend_errors) only appear in the deleted table, so this row looks like a leftover. Dropping it clears up the contradiction.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 46864d8. Dropped the contradictory row from the mapping table; it's now listed only in the Deleted Legacy Metrics table where it belongs.

// rebuild of the backend client does not accumulate callbacks against stale health state.
//
// The returned *HealthProviderSetter lets the caller attach a live health.StatusProvider
// once it becomes available (health.Monitor is built after MonitorBackends is called in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parenthetical says health.Monitor "depends on the decorated client already existing", but buildHealthMonitor at core_vmcp.go:277 takes only cfg and deliberately passes the undecorated cfg.BackendClient. Its own comment at line 283 says as much: "Use the undecorated client so health checks do not emit backend-call telemetry."

So there's no construction-order cycle to work around. If buildHealthMonitor moves above the MonitorBackends call, the provider can be passed by value and HealthProviderSetter goes away entirely, along with its mutex, Set/get, the four-value return at line 102, and the if healthProviderSetter != nil block at core_vmcp.go:253.

Not blocking, but it's a fair bit of machinery for a problem that isn't there.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that buildHealthMonitor uses the undecorated client, so there's no construction-order dependency forcing this ordering. Reordering it before MonitorBackends and passing the health.StatusProvider by value would remove HealthProviderSetter, its mutex, Set/get, and the four-value return entirely. Agreed this is a real simplification, but it touches construction ordering across core.New and is a separate change from this PR's rename scope. Leaving as-is for now and will follow up separately rather than bundling a construction-order refactor into this review pass.

Comment thread pkg/vmcp/core/core_vmcp_test.go Outdated
// it was built for is never returned to a caller and so never has Close
// called on it. Table-driven over every error branch in New that runs after
// MonitorBackends succeeds (invalid AuditConfig, workflow-auditor construction
// failure, workflow-instrument creation failure, workflow validation failure,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing: the comment lists five branches including "workflow-instrument creation failure", but the table has four cases and that one isn't among them. The newWorkflowInstruments error path at core_vmcp.go:202 is the one left unexercised. Either add the case or trim the comment.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 46864d8. Trimmed the comment to only list the four exercised branches, and added a note explaining why the newWorkflowInstruments error path isn't covered: the OTel SDK doesn't return an error for a same-name instrument-kind conflict (confirmed empirically), so there's no input reachable through the public API that would trigger it.

…olhive

# Conflicts:
#	pkg/telemetry/middleware.go
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 30, 2026
Addresses #5956 review comments:
- HIGH pkg/telemetry/middleware.go:713,732 (3684968852): mcp_server label
  missing from http.server.request.duration and mcp.server.operation.duration,
  losing per-server breakdown the deleted proxy metrics carried
- HIGH pkg/telemetry/middleware.go:97 (3684968864): SSE connection lifetime
  recorded into http.server.request.duration's 10s-capped buckets; split into
  a dedicated stacklok.toolhive.proxy.sse_connection.duration histogram on
  wider buckets
- HIGH pkg/telemetry/providers/providers_strategy.go:150 (3684968870):
  OwnershipLabels used dotted OTel attribute keys as Prometheus label names,
  which expose verbatim (not underscore-escaped) under Prometheus 3.x's
  escaping=allow-utf-8 negotiation, splitting one ownership label into two
  incompatible families depending on scraper
- MEDIUM docs/telemetry-migration-guide.md:279 (3684968876): mapping table
  mixed real Prometheus names with OTel dotted names in one column; added a
  Prometheus Name column with correct _total/_seconds/_bucket/_percent suffixes
- LOW docs/telemetry-migration-guide.md:292 (3684968881): dropped a
  contradictory row listing a deleted metric as also renamed
- LOW pkg/vmcp/core/core_vmcp_test.go:173 (3684968894): trimmed a stale
  comment naming an untested branch that has no forceable path through the
  OTel SDK's public API

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 30, 2026
@glageju

glageju commented Jul 30, 2026

Copy link
Copy Markdown
Author

Addressed the seven inline findings in 46864d8: mcp_server label restored on the two proxy request metrics, SSE connection duration split into its own histogram, the OwnershipLabels escaping split fixed, migration guide mapping table corrected, and the two small doc/comment issues cleaned up.

On the cutover strategy: I don't have a counter-argument to D13 only covering the 6 deletions, not the 18 renames. Dual-emitting the renamed metrics behind a flag defaulting to on, keeping the 6 deleted twins one more release, and adding a startup WARN is a reasonable path and consistent with how span attributes are already handled via --otel-use-legacy-attributes. That's a design change beyond what I can fold into responses to this review's inline comments, so I'm not implementing it here. Will follow up on scoping that as a separate change.

The test asserted http_response_status_code="200" >= 1 to prove an
authorized SSE tool call succeeded. That assertion was only ever
satisfied by the SSE connection-open GET's default 200 status leaking
into the same metric family — an authorized tools/call POST over SSE
transport gets 202 Accepted (the result arrives async over the stream),
never 200. Splitting SSE connection duration into its own histogram
(46864d8) removed that incidental 200, exposing the gap.

Drop the 200 assertion and make the tools/call-count assertion
(mcp_server_operation_duration_seconds_count) unconditional — it
already existed as the correct signal but was gated behind a
non-fatal substring check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jul 30, 2026

@ChrisJBurns ChrisJBurns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cutover strategy: remove legacy attributes, but keep renamed metrics behind a flag

Seconding @aponcedeleonch's request — this is the one thing I'd want resolved before merge, and I want to add some evidence for why the rename half is the more dangerous half.

The position: retiring the legacy span attributes is fine. That surface already has a compatibility story — dual emission behind --otel-use-legacy-attributes, with #6072 tracking its retirement. Users get a flag, a window, and a documented path.

Renaming metrics has none of that today, and it should get the same treatment:

  • Dual-emit the renamed metrics under both old and new names, behind an --otel-legacy-metrics flag defaulting to on.
  • Keep the 6 deleted twins one more release. They already coexist with their semconv replacements today, so keeping them costs nothing new.
  • Emit a startup WARN naming each deprecated metric and the release it will be removed in.
  • Document the deprecation — an explicit old → new table plus a stated removal release. The mapping table in this PR is already most of the way there; it needs to be framed as a deprecation schedule rather than a one-shot cutover.
  • Remove both in a later minor, and call it out prominently in the release notes.

Why the renames are worse than the deletions

I verified the counts against the mapping table at a8c3103b: 21 legacy toolhive_* metric names — 6 deleted, 15 renamed — plus 3 counters merged into outcome labels. After this PR, toolhive_ appears in zero metric names in non-test Go source.

The intuition here inverts, and I think it's the crux of the disagreement:

  • The 6 deletions are the least disruptive category. Their semconv replacements (mcp.server.operation.duration, mcp.client.operation.duration) already exist and are already emitted today, alongside the twins. A user can migrate their queries before upgrading, run both, confirm they agree, then upgrade. That overlap window is exactly what RFC D13's argument rests on.
  • The 15 renames + 3 merges have no overlap window at all. The old name stops and the new name starts in the same release. There is no moment where a user can run both and compare.

So D13 justifies the category that was already safe, and doesn't reach the category that isn't. A rename empties a dashboard panel exactly the same way a deletion does.

It also compounds: on the rate-limit metrics both the metric and its label change (toolhive_rate_limit_decisions{server=…}stacklok_toolhive_ratelimit_decisions_total{mcp_server=…}), so those dashboards break twice over.

Three breakages not currently in the guide

  1. Histogram bucket boundaries change on metrics whose names survive. mcp.server.operation.duration moves from BucketsMCPSemconv() […0.02, 0.2, 2…] to BucketsMCPProxy() […0.25, 2.5…]. Any histogram_quantile() spanning the upgrade mixes two incompatible layouts and returns silently wrong numbers. This is also the unresolved #6144 conflict — see the inline comment.
  2. telemetry.MCPHistogramBuckets is deleted from a public package. Exported identifier in github.com/stacklok/toolhive/pkg/telemetry, currently referenced from six places in-repo. External importers break at compile time, not just at query time. Not mentioned anywhere in the guide.
  3. stacklok.build_info is documented under a name Prometheus never emits. It exports as stacklok_build_info_ratio (the WithUnit("1")_ratio rule). Reproduced against this repo's pinned deps. So even a user who does the migration work lands on a query matching nothing.

And docs/telemetry-migration-guide.md:213 still tells operators "Your existing dashboards and alerts continue to work without changes" — which contradicts the Backward Compatibility table two sections above it.

One note on risk assessment: green CI isn't much signal here. The repo ships no PrometheusRule or alert artifacts, so nothing in-repo can catch a bad metrics migration. The first signal is a customer's dashboard going blank.


Review findings

Reviewed at a8c3103b across six specialist passes (OTel semconv, Go concurrency/lifecycle, backwards-compat/docs, test coverage, security/config, architecture). Claims marked verified were reproduced against this repo's pinned dependencies.

Already fixed in 46864d8e — re-verified, no action needed: mcp_server restored on both proxy request metrics; OwnershipLabels switched to literal Prometheus label names; SSE split into its own stacklok.toolhive.proxy.sse_connection.duration on BucketsMCPProxy(). Good fixes.

Consensus summary

Severity Finding Location
HIGH http.request.method recorded raw — unbounded attacker-controlled cardinality; semconv _OTHER normalization missing middleware.go:747
HIGH Semconv histogram buckets switched to the coarser proxy preset — reverts #6144, breaks quantile continuity middleware.go:86
HIGH stacklok.build_info exports as stacklok_build_info_ratio; test passes on substring buildinfo_test.go:41
HIGH Step 1 still promises existing dashboards keep working telemetry-migration-guide.md:213
HIGH Documented cardinality bound doesn't exist — gen_ai.tool.name gets the same unbounded value observability.md:231
MEDIUM build_info registered per-middleware, not once per process, with no unregister — doc overclaims middleware.go:127
MEDIUM mcp.method.name is an unvalidated JSON-RPC method string from the request body middleware.go:707
MEDIUM backendHealth.states never pruned — unbounded map keyed by workload ID backendtelemetry.go:242
MEDIUM Health gauge flips to unhealthy on context.Canceled and auth failures backendtelemetry.go:373
MEDIUM HealthProviderSetter's two-phase init rests on a premise that isn't true backendtelemetry.go:203
MEDIUM Unregister cleanup manually replicated across 5 error paths core_vmcp.go:163
MEDIUM Optimizer tool_name is unvalidated client input, now multiplied by outcome factory.go:418
MEDIUM Test: "health monitor build fails" case is vacuous; doc comment claims a 5th branch with no case core_vmcp_test.go:226
MEDIUM Test: no dp.Count assertion (double-record passes) and no 4xx case for the documented narrowing middleware_test.go:2555
MEDIUM Test: tautological guard gates the positive assertions integration_test.go:181
MEDIUM Test: e2e helper passes silently on zero regex matches telemetry_metrics_validation_e2e_test.go:360
MEDIUM Test: validateNoEmptyLabels has no zero-match guard after narrowing to one metric telemetry_metrics_validation_e2e_test.go:334
MEDIUM Test: dropped server-identity assertions without replacement telemetry_integration_test.go:338
MEDIUM "What Is New" table missing the three genuinely new metrics telemetry-migration-guide.md:29

Not anchorable inline (outside the diff)

  • examples/otel/grafana-dashboards/toolhive-mcp-otel-semconv-dashboard.json:246 and :974 — the semconv dashboard's own Error Rate panels still use error_type!="", which is exactly the 4xx trap the migration guide warns about. The other three dashboards were correctly moved to http_response_status_code=~"[45].."; these two weren't, so the shipped dashboards now disagree on what "error rate" means and this one silently under-reports 403s and 429s.
  • docs/arch/10-virtual-mcp-architecture.md:885 — still references the removed mcp_method label. CLAUDE.md requires docs/arch/ to be updated when architecture changes.
  • .claude/agents/site-reliability-engineer.md:23 — still points at the deleted pkg/vmcp/server/telemetry.go.
  • Three rewritten dashboards lack sum by (...)rate(mcp_server_operation_duration_seconds_count{…}) with legendFormat: "{{mcp_method_name}}" and no aggregation fans tools/call out to one series per tool, all rendered with the same legend string. The semconv dashboard does this correctly (sum by (le, mcp_method_name)), so the four are now inconsistent.
  • docs/operator/virtualmcpserver-observability.md — the migration guide now points readers here for the optimizer and revision_reclassifications metrics, but this doc documents none of them.

Worth noting on the positive side

The callback lifecycle work holds up under scrutiny: every one of the five post-registration error paths in core.New unregisters, Close() is idempotent (the SDK's Unregister is safe twice), the observable callback is genuinely non-blocking (registry.List is an RLock map copy), and the shared state it touches is race-free. The rename itself is also complete — repo-wide greps turn up no missed producer or consumer in deploy/, charts, ServiceMonitors, or alert rules. And replacing nil instruments with noop.* fallbacks quietly fixes a pre-existing panic path.

Scope

At 751 lines of production change across 9 files this is ~88% over the 400-line budget in CLAUDE.md, and three genuine behaviour changes (the health gauge, http.server.request.duration, the D8 hardening) are interleaved with a rename-dominated diff. Given the PR is already 20+ commits deep I don't think splitting now is worth it — but the backend-health gauge is the piece that would most have benefited from separate review.

🤖 Generated with Claude Code

ctx context.Context, method string, statusCode int, duration time.Duration,
) {
specAttrs := []attribute.KeyValue{
attribute.String("http.request.method", method),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — http.request.method is recorded raw: unbounded, attacker-controlled cardinality.

Go's net/http accepts any RFC 7230 token as a method, and /metrics plus the MCP port are unauthenticated by default here. curl -X ZZZZ1, -X ZZZZ2, … mints a permanently-retained series per distinct method — each multiplied across the bucket set — in both the SDK's in-memory aggregation and Prometheus.

OTel HTTP semconv mandates exactly the missing mitigation: methods outside the known set MUST be recorded as _OTHER, with the original kept on spans only (http.request.method_original, which should not go on the metric).

The deleted toolhive_mcp_requests carried the same raw method, so this isn't net-new — but it's reintroduced on a metric this PR presents as semconv-compliant, and it's now the one metric recorded on every request path.

Separately, semconv lists url.scheme as Required on http.server.request.duration, and network.protocol.version / server.address as recommended. httpProtocolVersion(r) and mapTransport() are already computed a few lines down for mcp.server.operation.duration, so the data is at hand. http.route should be added only from a bounded route pattern — never the raw r.URL.Path.

metric.WithDescription("Duration of MCP server operations"),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(MCPHistogramBuckets...),
metric.WithExplicitBucketBoundaries(coremetrics.BucketsMCPProxy()...),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — this reverts #6144, and changes bucket edges on an already-shipped metric.

247480f16 ("Adopt core MCP semconv bucket preset in telemetry", #6144) landed on main and deliberately put this exact line on BucketsMCPSemconv(). This branch moves it to BucketsMCPProxy(). @aponcedeleonch flagged this as the live rebase conflict and it's still unresolved at a8c3103b.

toolhive-core's own doc comment says BucketsMCPSemconv exists "for emitters that promise semconv fidelity" and names mcp.server.operation.duration as the example — so a semconv-named metric should carry semconv buckets.

Beyond the revert, the edges themselves change: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, …][0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, …]. Any histogram_quantile() over a range spanning the upgrade mixes two incompatible layouts and returns silently wrong values. The migration guide documents zero bucket changes.

pkg/ratelimit/observability.go:80 and pkg/vmcp/core/core_telemetry.go:57 make the same substitution without conflicting textually and want the same look. On the latter: toolhive-core documents BucketsLongRunning() as being "for long-running measurements such as sync, reconcile, or composite-tool durations" — naming that exact use case.

handler.ServeHTTP(rw, req)
body := rw.Body.String()

require.Contains(t, body, "stacklok_build_info", "build_info metric should be exported")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — this assertion cannot fail for the property it claims to check, and the documented metric name doesn't exist.

coremetrics.RegisterBuildInfo sets metric.WithUnit("1"), and the Prometheus translator appends _ratio to any gauge with unit "1". I reproduced this against this repo's pinned deps:

stacklok_build_info_ratio{commit="abc123",component="toolhive",version="v1.2.3"} 1

require.Contains(t, body, "stacklok_build_info") is satisfied by stacklok_build_info_ratio as a substring, so the test passes while docs/observability.md:271 documents a name that matches nothing. Any fleet dashboard joining on stacklok_build_info returns empty.

Anchor the assertion at line start (^stacklok_build_info(_ratio)?\{) so the exported name is pinned, and reconcile with the docs. The durable fix is upstream — drop WithUnit("1") for a unitless identity gauge — worth filing before this vocabulary reaches customer dashboards.

Also worth asserting the labels: build_info always observes 1, so component="toolhive" / non-empty version / non-empty commit are the entire point of the metric, and none of them is currently checked.

@@ -178,15 +212,17 @@ When upgrading to this release, dual emission is enabled by default. Both old
and new attribute names appear on spans. Your existing dashboards and alerts
continue to work without changes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — this is now false, and it's the first thing an operator reads when deciding whether an upgrade is safe.

Six metrics are deleted, 15 renamed, 3 merged, and several label keys changed. The Backward Compatibility table two sections above explicitly states metrics are a hard cutover, no legacy fallback — this paragraph directly contradicts it.

The dual-emission reassurance is true only for span attributes. Suggest retitling to scope it explicitly, e.g. "Step 1: Upgrade — Span Attributes Need No Action", and stating plainly that metric-based dashboards and alerts do break, with a pointer to Step 2.

Related: Step 4 ("Update Dashboard Panels") still advises running old and new queries "side-by-side during migration to verify equivalence" — impossible for metrics with no dual emission. Worth rescoping to trace-based panels.

Comment thread docs/observability.md

#### `toolhive_mcp_tool_calls` (Counter)
> **Note**: `mcp_resource_id` (tool name, resource URI, or prompt name) is
> deliberately omitted from this metric's attributes to bound cardinality; it

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — this cardinality claim doesn't hold, and it will mislead operators into thinking the vector is closed.

recordOperationDuration (middleware.go:791) sets gen_ai.tool.name to resourceID — the same parsedMCP.ResourceID value, i.e. params.name straight from the client's JSON-RPC body. For tools/call and prompts/get the unbounded value simply moved to a different key on the same metric; only resources/read (the URI) genuinely lost it.

Since mcp.server.operation.duration is now the sole per-method metric after the twins were deleted, arbitrary params.name values grow the series set without bound.

Either keep gen_ai.* names on spans only, or bound them against the aggregated capability set the proxy already knows (emitting _OTHER for unresolved names). Either way the note needs correcting — as written it asserts a control that isn't there.

// The renamed active-connections gauge and the semconv HTTP duration must
// be present (both are recorded for every handled request).
if strings.Contains(metricsBody, "stacklok_toolhive_proxy") {
assert.Contains(t, metricsBody, metricActiveConnections)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — this guard is a prefix of the string it guards, so the assertion is tautological.

metricActiveConnections == "stacklok_toolhive_proxy_active_connections", and the guard is strings.Contains(metricsBody, "stacklok_toolhive_proxy"). The first assertion can therefore never fail, and the second only runs when the gauge already happens to be present. If the middleware emitted nothing at all, the whole block is skipped and the test still passes.

The test already made a real request through the middleware, so both series must exist — dropping the if and asserting unconditionally is strictly stronger.

(The NotContains assertions above this are unconditional and genuinely valuable — this is only about the guarded positive half.)

@@ -350,12 +358,13 @@ func validateNoEmptyLabels(metricsContent, expectedServerName, expectedTransport

// validateMetricValues validates that metric values are reasonable
func validateMetricValues(metricsContent, expectedServerName, expectedTransport string) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — the regex was weakened and the helper still can't fail.

The pattern went from toolhive_mcp_requests_total\{.*server="X".*transport="Y".*\} (\d+) to mcp_server_operation_duration_seconds_count\{[^}]*\} (\d+), dropping both the server-name and transport pinning. The surrounding if len(matches) > 0 { … } guard is left in place, so if the regex matches nothing the entire body — including Expect(totalRequests).To(BeNumerically(">", 0)) — is skipped and the helper passes silently.

Expect(matches).ToNot(BeEmpty(), "expected ... series in scrape") makes it capable of failing. Now that 46864d8e has restored mcp_server on this metric, the server-identity pinning can also come back.


for _, line := range lines {
if strings.Contains(line, "toolhive_mcp") && !strings.HasPrefix(line, "#") {
// The active-connections gauge is the series that carries both mcp_server

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — no zero-match guard after narrowing the scan from many series to one.

This previously scanned every line containing toolhive_mcp; it now scans only stacklok_toolhive_proxy_active_connections. If that single gauge is missing from the scrape for any reason — instrument creation fell back to the new noop.Int64UpDownCounter{}, a rename, an exporter change — the loop body never executes and the helper passes without asserting anything.

Unlike analyzeTraceAttributes, whose caller asserts TracesGenerated > 0 downstream, there's no backstop here. A checkedLines counter plus Expect(checkedLines).To(BeNumerically(">", 0)) closes it.

// Request duration histogram
assert.Contains(t, metrics, "toolhive_mcp_request_duration",
"Should record request duration histogram")
// --- Deleted legacy twins must be absent (single-pass cutover) ---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — server-identity assertions were dropped without replacement, in the one test that scrapes a real vMCP /metrics.

The rewrite deleted assert.Contains(metrics, 'server="telemetry-vmcp"'), 'transport="streamable-http"', and 'mcp_resource_id="search-svc_search"', replacing them only with mcp_method_name= checks. So the central servermcp_server rename this PR performs isn't verified end-to-end anywhere. Same in TestIntegration_TelemetryRunsBeforeClassificationRejection, where server="telemetry-ordering-vmcp" gave way to a status-code check.

mcp_server="telemetry-vmcp" now lands on stacklok_toolhive_proxy_active_connections and mcp_client_operation_duration_seconds, so asserting it is cheap. The transport= assertion is unaffected by this PR and could simply come back.

3. **Metric name and label standardization** — The legacy `toolhive_mcp_*` and
`toolhive_vmcp_*` metric names and their label vocabulary (`server`,
`mcp_method`, `tool`, `workflow.name`, …) have been replaced by the shared
`stacklok.*`/OTel-semconv vocabulary (`mcp_server`, `mcp_method_name`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — the "What Is New" table contradicts the "What Changed" section above it.

"What Changed" was updated to describe three new histograms, but this table still lists only mcp.server.operation.duration and mcp.client.operation.duration. Missing:

  • http.server.request.duration
  • stacklok.toolhive.proxy.sse_connection.duration (added in 46864d8e)
  • stacklok.build_info
  • stacklok.vmcp.mcp_server.health — semantically new, not a rename of toolhive_vmcp_backends_discovered
  • the new outcome label vocabulary

Worth adding here since this table is what a reader scans to find out what they gain in exchange for the queries they have to rewrite.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants